官方 Demo:https://vueuse.org/core/useInfiniteScroll/#useinfinitescroll
element
:必要參數,通常會傳入 scoll 容器的 DOM 元素。onLoadMore
:必要參數,在滾動到接近目標位置時,要執行的 function。options
:
direction
:可以是 top
、right
、bottom
、left
,預設為 bottom
,決定目標位置的方向。例如 bottom
的話就是滾動到底部的目標位置時,觸發 onLoadMore
。distance
:預設為 0。假設設定 10,direction
是 bottom 的話,滾動到離底部 10px 的距離就會觸發 onLoadMore
。interval
:決定等待多少 ms 後,才能再次觸發 onLoadMoreÍ
,預設為 100ms。假如設定為 500ms,即使 user 快速滾動到底部,系統也會確保每次執行 onLoadMore
之間至少間隔 500 毫秒。canLoadMore
:預設為 () => true
。舉例來說,假設已經從 API 知道沒有下一頁的資料可以拿了,可以把判斷寫在 canLoadMore
讓他 return false,這樣就不會進一步執行 onLoadMore
。先看官方 Demo 的 source code,這部分搭配官方 Demo 畫面會比較有感覺:
<!-- src/components/UseInfiniteScrollDemo.vue -->
<script setup>
import { ref } from 'vue'
import { useInfiniteScroll } from '@/compositions/useInfiniteScroll'
const el = ref(null)
const data = ref([])
const { reset } = useInfiniteScroll(
el,
() => {
const length = data.value.length + 1
data.value.push(...Array.from({ length: 5 }, (_, i) => length + i))
},
{ distance: 10 },
)
function resetList() {
data.value = []
reset()
}
</script>
<template>
<h2>UseInfiniteScroll Demo</h2>
<div ref="el" class="flex flex-col gap-2 p-4 w-300px h-300px m-auto overflow-y-scroll bg-gray-500/5 rounded">
<div v-for="item in data" :key="item" class="h-15 bg-gray-500/5 rounded p-3">
{{ item }}
</div>
</div>
<button @click="resetList()">
Reset
</button>
</template>
可以看到傳給 useInfiniteScroll
的參數,第一個參數 el 就是畫面中那個 scroll container,第二個先跳過,第三個參數設定 distance 為 10,用途參考前面參數介紹。接著來看重點,第二個參數:
() => {
const length = data.value.length + 1
data.value.push(...Array.from({ length: 5 }, (_, i) => length + i))
}
在滾動到底部的時候,會執行這個 function。
這邊使用到 Array.from({ length: 5 }, (_, i) => length + i)
,第一次執行的時候會拿到 [1, 2, 3, 4, 5],第二次執行會拿到 [6, 7, 8, 8, 10],依此類推,詳細用法可以再參考 mdn~
這邊因為是用 push,所以會有 Demo 那種一直往下增加的效果。
另外可以看到 useInfiniteScroll
有回傳 reset
這個 function,在 data.value = []
後,scroll container 這個 DOM 的相關數值會變動,需要透過 reset
來重新計算。
useInfiniteScroll 的原始碼行數不多,這邊就先貼全部,再分段來看:
// src/compositions/useInfiniteScroll.js
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { tryOnUnmounted } from '@/utils/shared'
import { resolveElement, toValue } from '@/helper'
import { useElementVisibility } from '@/compositions/useElementVisibility'
import { useScroll } from '@/compositions/useScroll'
export function useInfiniteScroll(
element,
onLoadMore,
options = {},
) {
const {
direction = 'bottom',
interval = 100,
canLoadMore = () => true,
} = options
const state = reactive(useScroll(
element,
{
...options,
offset: {
[direction]: options.distance ?? 0,
...options.offset,
},
},
))
const promise = ref()
const isLoading = computed(() => !!promise.value)
const observedElement = computed(() => {
return resolveElement(toValue(element))
})
const isElementVisible = useElementVisibility(observedElement)
function checkAndLoad() {
state.measure()
if (!observedElement.value || !isElementVisible.value || !canLoadMore(observedElement.value))
return
const { scrollHeight, clientHeight, scrollWidth, clientWidth } = observedElement.value
const isNarrower = (direction === 'bottom' || direction === 'top')
? scrollHeight <= clientHeight
: scrollWidth <= clientWidth
if (state.arrivedState[direction] || isNarrower) {
if (!promise.value) {
promise.value = Promise.all([
onLoadMore(state),
new Promise(resolve => setTimeout(resolve, interval)),
])
.finally(() => {
promise.value = null
nextTick(() => checkAndLoad())
})
}
}
}
const stop = watch(
() => [state.arrivedState[direction], isElementVisible.value],
checkAndLoad,
{ immediate: true },
)
tryOnUnmounted(stop)
return {
isLoading,
reset() {
nextTick(() => checkAndLoad())
},
}
}
接下來從觸發點開始看:
const stop = watch(
() => [state.arrivedState[direction], isElementVisible.value],
checkAndLoad,
{ immediate: true },
)
假設 direction 為 bottom
、distance
為 0;
state.arrivedState[direction]
的值是 true
或是 false
,如果是 true
的話代表當下已經滾動到最底部。詳細可以參考之前看過的 Day 21 useScroll。
isElementVisible.value
的值是 true
或是 false
,如果是 false
,以 Demo 為例的話,就是 scroll container 不在畫面中,既然不在畫面中,就不需要進行計算(等等在核心程式碼中會看到)。isElementVisible
詳細可以參考之前看過的 Day 24 useElementVisibility。
以上這兩個值變動時,都會觸發 useInfiniteScroll 的核心 - checkAndLoad
。
接著聚焦在 checkAndLoad
,最一開始呼叫的 state.measure()
也是 useScroll 回傳的東西,主要是強制 useScroll 做一些 scroll container DOM 相關數值的計算與更新,像是剛剛提到的 state.arrivedState
也是其中之一,沒有做這個更新的話,state.arrivedState
會拿到 useScroll 設定的預設回傳值,Day 22 的 return measure 區塊有大概提到。
接著來看判斷式:
if (!observedElement.value || !isElementVisible.value || !canLoadMore(observedElement.value))
return
!observedElement.value
這個滿明顯的,沒有 scroll container 好像就沒得玩了 XD!isElementVisible.value
剛剛有提到,scroll container 不在畫面中的時候,不需要做計算。!canLoadMore(observedElement.value)
這個可以參考最前面的參數介紹~
關於 isNarrower 的判斷:
const { scrollHeight, clientHeight, scrollWidth, clientWidth } = observedElement.value
const isNarrower = (direction === 'bottom' || direction === 'top')
? scrollHeight <= clientHeight
: scrollWidth <= clientWidth
if (state.arrivedState[direction] || isNarrower) {
// ... 略
}
isNarrower
看起來是 scroll container 本來就不用 scroll 的時候會為 true,不過這段我有點疑問,因為理論上在這種情境,state.arrivedState[direction] 也會是 true 才對,不太確定為什麼還要多這個 isNarrower
的判斷。以 Demo code 來說,把 isNarrower 判斷拿掉效果是一樣的。可能有我沒想到的邊界情境要考慮吧(?)
接著看核心最重要的一段:
if (state.arrivedState[direction] || isNarrower) {
if (!promise.value) {
promise.value = Promise.all([
onLoadMore(state),
new Promise(resolve => setTimeout(resolve, interval)),
])
.finally(() => {
promise.value = null
nextTick(() => checkAndLoad())
})
}
}
假設 interval 為 300ms
這邊是用 Promise.all
來處理,假設我們傳入的 onLoadMore 是一個要 call API 拿資料的 async function(會 return Promise),用 Promise.all
的好處是,如果 API 比 300ms 還久,會以 async function 執行完的時間為主,如果小於 300ms,那最少也要等到 300ms 才能觸發下一次的 checkAndLoad
。
這邊需要使用 nextTick(() => checkAndLoad())
是因為在執行下一次的 checkAndLoad
時,要確保 checkAndLoad
是拿最新狀態的 DOM 來做計算。
GitHub PR:https://github.com/RhinoLee/30days_vue/pull/25/files
useInfiniteScroll 就到這邊告一段落啦~ 明天來看 useInfiniteScroll 的 unit test 是怎麼測的。